ποΈGitΠ―ΡΠ°ποΈ
Commit 0f123adb72d3d13a208959a17f7d9dd40809804b
Parents : c45466a
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-06-02T12:12:35-05:00
Committer : GitHub <noreply@github.com>
Date : 2026-06-02T17:12:35Z
fix(map): eliminate cluster-renderer FATAL and harden black-map paths (#5715)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Changes
4 files changed, 216 insertions(+), 87 deletions(-)
Diff
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
index 40f2756d9d..3c76f7b00b 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
@@ -186,6 +186,11 @@ fun MapView(
mode: GoogleMapMode = GoogleMapMode.Main,
) {
val context = LocalContext.current
+
+ // Initialize the Maps SDK up front (idempotent) so the loaded renderer is logged even when the mesh has
+ // no nodes/waypoints to build marker descriptors from. See MapsSdkInitializer.
+ LaunchedEffect(Unit) { MapsSdkInitializer.ensureInitialized(context) }
+
val coroutineScope = rememberCoroutineScope()
val mapLayers by mapViewModel.mapLayers.collectAsStateWithLifecycle()
@@ -492,7 +497,31 @@ fun MapView(
val onRemoveLayer = { layerId: String -> mapViewModel.removeMapLayer(layerId) }
val onToggleVisibility = { layerId: String -> mapViewModel.toggleLayerVisibility(layerId) }
- val effectiveGoogleMapType = if (currentCustomTileProviderUrl != null) MapType.NONE else selectedGoogleMapType
+ // Resolve the selected custom tile provider once (cached). getTileProvider returns null when the
+ // configured source is unusable (bad {x}/{y}/{z} URL template, missing local MBTiles file, etc.).
+ val customTileConfigs by mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle()
+ val customTileProvider =
+ remember(currentCustomTileProviderUrl, customTileConfigs) {
+ currentCustomTileProviderUrl?.let { url ->
+ val config = customTileConfigs.find { it.urlTemplate == url || it.localUri == url }
+ mapViewModel.getTileProvider(config)
+ }
+ }
+
+ // Only blank the Google base map (MapType.NONE) when we actually have a working custom basemap to draw
+ // over it. If the selected custom source failed to build, fall back to the user's base map instead of
+ // rendering MapType.NONE with no tiles β that is a solid black screen with no recourse.
+ val effectiveGoogleMapType = if (customTileProvider != null) MapType.NONE else selectedGoogleMapType
+
+ // Surface the fallback so a broken custom tile source is diagnosable instead of a silent black map.
+ LaunchedEffect(currentCustomTileProviderUrl, customTileProvider) {
+ if (currentCustomTileProviderUrl != null && customTileProvider == null) {
+ Logger.withTag("MapView").w {
+ "Custom tile provider '$currentCustomTileProviderUrl' could not be built; " +
+ "falling back to base map $selectedGoogleMapType"
+ }
+ }
+ }
var showClusterItemsDialog by remember { mutableStateOf<List<NodeClusterItem>?>(null) }
@@ -541,16 +570,11 @@ fun MapView(
}
},
) {
- // Custom tile overlay (all modes)
+ // Custom tile overlay (all modes) β uses the hoisted provider so the base-map decision above and
+ // this overlay stay consistent (no overlay β base map is shown, never a black MapType.NONE).
key(currentCustomTileProviderUrl) {
- currentCustomTileProviderUrl?.let { url ->
- val config =
- mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle().value.find {
- it.urlTemplate == url || it.localUri == url
- }
- mapViewModel.getTileProvider(config)?.let { tileProvider ->
- TileOverlay(tileProvider = tileProvider, fadeIn = true, transparency = 0f, zIndex = -1f)
- }
+ customTileProvider?.let { tileProvider ->
+ TileOverlay(tileProvider = tileProvider, fadeIn = true, transparency = 0f, zIndex = -1f)
}
}
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapsSdkInitializer.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapsSdkInitializer.kt
new file mode 100644
index 0000000000..c8fad692ac
--- /dev/null
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapsSdkInitializer.kt
@@ -0,0 +1,61 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.app.map
+
+import android.content.Context
+import co.touchlab.kermit.Logger
+import com.google.android.gms.maps.MapsInitializer
+import java.util.concurrent.atomic.AtomicBoolean
+
+/**
+ * Centralized, run-once Google Maps SDK initialization for the google flavor.
+ *
+ * Two things happen here, deliberately decoupled:
+ * 1. **Synchronous init** via the single-arg [MapsInitializer.initialize] overload. This is the only overload
+ * documented as synchronous, and it guarantees `BitmapDescriptorFactory` is ready before any eager Canvas descriptor
+ * is built off a live `GoogleMap` (the node-detail inline map builds its icon before its map loads β see #5709 and
+ * `MarkerBitmapRenderer`). It is idempotent, so repeated calls are no-ops.
+ * 2. **Renderer reporting** via the callback overload, registered exactly once. We can no longer *force* a renderer
+ * (the LEGACY renderer was decommissioned in March 2025, so a preference is honored only as a hint), but the
+ * documented "Latest renderer" tile-rendering failures can still leave the base map blank on some devices. Logging
+ * which renderer actually loaded lets us correlate "black map" field reports in Crashlytics/Datadog. Kermit's
+ * [Logger] is the sink because `GooglePlatformAnalytics` wires its Crashlytics/Datadog log writers at startup while
+ * delaying SDK init until consent β so logging through Kermit is the privacy-correct, already-sanctioned path (vs.
+ * touching `Firebase.crashlytics` directly).
+ */
+object MapsSdkInitializer {
+
+ private val callbackRegistered = AtomicBoolean(false)
+
+ fun ensureInitialized(context: Context) {
+ val app = context.applicationContext
+
+ // (1) Synchronous readiness guarantee β see kdoc. Deprecated overload retained intentionally.
+ @Suppress("DEPRECATION")
+ MapsInitializer.initialize(app)
+
+ // (2) Register the renderer-reporting callback once. The SDK is already initialized above, so the
+ // callback fires promptly with the renderer that actually loaded.
+ if (callbackRegistered.compareAndSet(false, true)) {
+ MapsInitializer.initialize(app, MapsInitializer.Renderer.LATEST) { renderer ->
+ Logger.withTag(TAG).i { "Google Maps renderer loaded: $renderer" }
+ }
+ }
+ }
+
+ private const val TAG = "MapsSdkInitializer"
+}
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MarkerBitmapRenderer.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MarkerBitmapRenderer.kt
index 9b0c161eb8..baf8070705 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MarkerBitmapRenderer.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MarkerBitmapRenderer.kt
@@ -27,9 +27,9 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.core.graphics.createBitmap
-import com.google.android.gms.maps.MapsInitializer
import com.google.android.gms.maps.model.BitmapDescriptor
import com.google.android.gms.maps.model.BitmapDescriptorFactory
+import org.meshtastic.app.map.MapsSdkInitializer
import org.meshtastic.core.model.Node
private const val CHIP_CORNER_RADIUS_DP = 4f
@@ -51,11 +51,23 @@ fun rememberNodeChipDescriptor(node: Node): BitmapDescriptor {
val density = LocalDensity.current.density
val fontScale = LocalDensity.current.fontScale
return remember(node.num, node.user.short_name, node.colors, node.isIgnored) {
- ensureMapsInitialized(context)
- renderNodeChipBitmap(node, density, fontScale)
+ buildNodeChipDescriptor(context, node, density, fontScale)
}
}
+/**
+ * Non-`@Composable` variant of [rememberNodeChipDescriptor] for callers that have no composition to read
+ * [LocalContext]/[LocalDensity] from β specifically a [com.google.maps.android.clustering.view.DefaultClusterRenderer]
+ * building marker icons on its background render thread. Keeping the cluster icon on this Canvas path (instead of
+ * maps-compose's `clusterItemContent` Composable) avoids the off-screen ComposeView in `ComposeUiClusterRenderer`,
+ * which has no reachable `ViewTreeLifecycleOwner` from the async render Handler and was our top FATAL
+ * (googlemaps/android-maps-compose#325/#875).
+ */
+fun buildNodeChipDescriptor(context: Context, node: Node, density: Float, fontScale: Float): BitmapDescriptor {
+ ensureMapsInitialized(context)
+ return renderNodeChipBitmap(node, density, fontScale)
+}
+
/** Renders an emoji waypoint marker as a [BitmapDescriptor] using Canvas. */
@Composable
fun rememberEmojiMarkerDescriptor(codePoint: Int): BitmapDescriptor {
@@ -72,12 +84,11 @@ fun rememberEmojiMarkerDescriptor(codePoint: Int): BitmapDescriptor {
* [BitmapDescriptorFactory] only works after the Maps SDK has been initialized, which normally happens when a
* GoogleMap/MapView is created. These descriptors are built during composition, and on the node-detail inline map the
* icon is computed before that screen's GoogleMap has loaded the SDK β so [BitmapDescriptorFactory.fromBitmap] crashes
- * with "IBitmapDescriptorFactory is not initialized". Initialize explicitly first; [MapsInitializer.initialize] is
- * synchronous and idempotent, so it is a no-op once the SDK is already up.
+ * with "IBitmapDescriptorFactory is not initialized". Delegate to [MapsSdkInitializer], which initializes the SDK
+ * synchronously and idempotently (and reports the loaded renderer), so this is a no-op once the SDK is already up.
*/
-@Suppress("DEPRECATION")
private fun ensureMapsInitialized(context: Context) {
- MapsInitializer.initialize(context)
+ MapsSdkInitializer.ensureInitialized(context)
}
private fun renderNodeChipBitmap(node: Node, density: Float, fontScale: Float): BitmapDescriptor {
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt
index 6d38e176af..e160fb6f55 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt
@@ -16,30 +16,38 @@
*/
package org.meshtastic.app.map.component
+import android.content.Context
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.platform.LocalView
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.compose.LocalLifecycleOwner
-import androidx.lifecycle.compose.currentStateAsState
-import androidx.lifecycle.findViewTreeLifecycleOwner
-import androidx.lifecycle.setViewTreeLifecycleOwner
-import androidx.savedstate.compose.LocalSavedStateRegistryOwner
-import androidx.savedstate.findViewTreeSavedStateRegistryOwner
-import androidx.savedstate.setViewTreeSavedStateRegistryOwner
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import com.google.android.gms.maps.GoogleMap
+import com.google.android.gms.maps.model.Marker
+import com.google.android.gms.maps.model.MarkerOptions
import com.google.maps.android.clustering.Cluster
+import com.google.maps.android.clustering.ClusterManager
import com.google.maps.android.clustering.view.DefaultClusterRenderer
import com.google.maps.android.compose.Circle
+import com.google.maps.android.compose.MapEffect
import com.google.maps.android.compose.MapsComposeExperimentalApi
import com.google.maps.android.compose.clustering.Clustering
-import com.google.maps.android.compose.clustering.ClusteringMarkerProperties
+import com.google.maps.android.compose.clustering.rememberClusterManager
import org.meshtastic.app.map.model.NodeClusterItem
import org.meshtastic.feature.map.BaseMapViewModel
+private const val MIN_CLUSTER_SIZE = 10
+
+// Match the bottom-center anchor maps-compose used for the old `clusterItemContent` chip
+// (clusterItemContentAnchor defaults to Offset(0.5f, 1.0f)), so chips keep sitting on the node coordinate.
+private const val CHIP_ANCHOR_U = 0.5f
+private const val CHIP_ANCHOR_V = 1.0f
+
@OptIn(MapsComposeExperimentalApi::class)
-@Suppress("NestedBlockDepth")
@Composable
fun NodeClusterMarkers(
nodeClusterItems: List<NodeClusterItem>,
@@ -47,68 +55,93 @@ fun NodeClusterMarkers(
navigateToNodeDetails: (Int) -> Unit,
onClusterClick: (Cluster<NodeClusterItem>) -> Boolean,
) {
- val view = LocalView.current
- val lifecycleOwner = LocalLifecycleOwner.current
- val savedStateRegistryOwner = LocalSavedStateRegistryOwner.current
- val lifecycleState by lifecycleOwner.lifecycle.currentStateAsState()
+ val context = LocalContext.current
+ val density = LocalDensity.current.density
+ val fontScale = LocalDensity.current.fontScale
- // maps-compose renders each non-clustered item to a bitmap through an off-screen ComposeView that
- // it attaches under the MapView (see ComposeUiClusterRenderer + NoDrawContainerView in
- // MapComposeViewRender). That ComposeView walks up the view tree for a ViewTreeLifecycleOwner and,
- // when it finds none, crashes with "Composed into the View which doesn't propagate
- // ViewTreeLifecycleOwner!" (googlemaps/android-maps-compose#875 / #325) β a FATAL on the map screen.
- //
- // Propagate the owners onto this map screen's host view (LocalView.current), which is an ancestor
- // of the internally-created MapView, so the renderer's ComposeView can resolve them. We deliberately
- // do NOT touch view.rootView (the activity root): attaching a transient NavEntry lifecycle there is
- // what caused the node-list popup regression (#5684), which is why #5704 removed the prior, broader
- // workaround entirely. Scoping to the map host view and restoring the previous owners on dispose
- // keeps the fix local to the map and leaves Popups/DropdownMenus untouched.
- DisposableEffect(view, lifecycleOwner, savedStateRegistryOwner) {
- val prevLifecycleOwner = view.findViewTreeLifecycleOwner()
- val prevSavedStateRegistryOwner = view.findViewTreeSavedStateRegistryOwner()
- view.setViewTreeLifecycleOwner(lifecycleOwner)
- view.setViewTreeSavedStateRegistryOwner(savedStateRegistryOwner)
- onDispose {
- view.setViewTreeLifecycleOwner(prevLifecycleOwner)
- view.setViewTreeSavedStateRegistryOwner(prevSavedStateRegistryOwner)
- }
+ val clusterManager = rememberClusterManager<NodeClusterItem>() ?: return
+
+ // Render each non-clustered node as a Canvas-built BitmapDescriptor through a custom
+ // DefaultClusterRenderer instead of passing a `clusterItemContent` Composable. The Composable path makes
+ // maps-compose rasterize the chip through an off-screen ComposeView (ComposeUiClusterRenderer); that view
+ // walks the view tree for a ViewTreeLifecycleOwner/SavedStateRegistryOwner and, because the cluster
+ // renderer drives marker creation from an async Handler after the screen may have stopped, finds none and
+ // crashes with "Composed into the View which doesn't propagate ViewTreeLifecycleOwner!"
+ // (googlemaps/android-maps-compose#325 / #875) β historically our #1 FATAL. A renderer that paints the icon
+ // in onBeforeClusterItemRendered never creates a View, so the crash class is eliminated rather than raced
+ // against (see the owner-propagation workarounds in #5704/#5708 that could not win that race).
+ val rendererState: MutableState<NodeChipClusterRenderer?> = remember { mutableStateOf(null) }
+
+ MapEffect(clusterManager, density, fontScale) { map ->
+ val renderer = NodeChipClusterRenderer(context, map, clusterManager, density, fontScale)
+ renderer.minClusterSize = MIN_CLUSTER_SIZE
+ clusterManager.renderer = renderer
+ rendererState.value = renderer
}
- // The cluster renderer drives marker rendering from an async Handler (DefaultClusterRenderer's
- // MarkerModifier), which can fire after this screen has stopped and the internal ComposeView is
- // detached β at which point no owner is reachable regardless of the above. Skip rendering once the
- // lifecycle is no longer at least STARTED to close most of that race.
- if (!lifecycleState.isAtLeast(Lifecycle.State.STARTED)) return
+ SideEffect {
+ clusterManager.setOnClusterClickListener(onClusterClick)
+ clusterManager.setOnClusterItemInfoWindowClickListener { item -> navigateToNodeDetails(item.node.num) }
+ }
+
+ Clustering(items = nodeClusterItems, clusterManager = clusterManager)
- Clustering(
- items = nodeClusterItems,
- onClusterClick = onClusterClick,
- onClusterItemInfoWindowClick = { item ->
- navigateToNodeDetails(item.node.num)
- false
- },
- clusterItemContent = { clusterItem -> PulsingNodeChip(node = clusterItem.node) },
- onClusterManager = { clusterManager ->
- (clusterManager.renderer as DefaultClusterRenderer).minClusterSize = 10
- },
- clusterItemDecoration = { clusterItem ->
- if (mapFilterState.showPrecisionCircle) {
- clusterItem.getPrecisionMeters()?.let { precisionMeters ->
- if (precisionMeters > 0) {
- Circle(
- center = clusterItem.position,
- radius = precisionMeters,
- fillColor = Color(clusterItem.node.colors.second).copy(alpha = 0.2f),
- strokeColor = Color(clusterItem.node.colors.second),
- strokeWidth = 2f,
- zIndex = 0f,
- )
- }
+ // The library's `clusterItemDecoration` only fires for its internal ComposeUiClusterRenderer (the gating
+ // ClusterRendererItemState type is library-internal), so it never runs for our custom renderer. Draw the
+ // precision circles ourselves for exactly the unclustered items the renderer exposes, preserving the prior
+ // "circle only on non-clustered nodes" behavior.
+ val renderer = rendererState.value
+ if (renderer != null && mapFilterState.showPrecisionCircle) {
+ val unclusteredItems by renderer.unclusteredItems
+ unclusteredItems.forEach { item ->
+ item.getPrecisionMeters()?.let { precisionMeters ->
+ if (precisionMeters > 0) {
+ Circle(
+ center = item.position,
+ radius = precisionMeters,
+ fillColor = Color(item.node.colors.second).copy(alpha = 0.2f),
+ strokeColor = Color(item.node.colors.second),
+ strokeWidth = 2f,
+ zIndex = 0f,
+ )
}
}
- // Use the item's own priority-based zIndex (5f for My Node/Favorites, 4f for others)
- ClusteringMarkerProperties(zIndex = clusterItem.getZIndex())
- },
- )
+ }
+ }
+}
+
+/**
+ * A [DefaultClusterRenderer] that draws each non-clustered node's chip as a Canvas [BitmapDescriptor]
+ * ([buildNodeChipDescriptor]) instead of a Composable, avoiding maps-compose's crash-prone off-screen ComposeView
+ * rasterization. It also exposes the current set of unclustered items so the caller can draw precision circles (the
+ * library's `clusterItemDecoration` hook is unavailable to non-library renderers).
+ */
+private class NodeChipClusterRenderer(
+ private val context: Context,
+ map: GoogleMap,
+ clusterManager: ClusterManager<NodeClusterItem>,
+ private val density: Float,
+ private val fontScale: Float,
+) : DefaultClusterRenderer<NodeClusterItem>(context, map, clusterManager) {
+
+ val unclusteredItems: MutableState<Set<NodeClusterItem>> = mutableStateOf(emptySet())
+
+ // Called on a background render thread β building the descriptor here keeps marker rasterization off the
+ // main thread, and BitmapDescriptorFactory is safe once the SDK is initialized (the live map guarantees it).
+ override fun onBeforeClusterItemRendered(item: NodeClusterItem, markerOptions: MarkerOptions) {
+ markerOptions
+ .icon(buildNodeChipDescriptor(context, item.node, density, fontScale))
+ .anchor(CHIP_ANCHOR_U, CHIP_ANCHOR_V)
+ .zIndex(item.getZIndex())
+ }
+
+ override fun onClusterItemUpdated(item: NodeClusterItem, marker: Marker) {
+ marker.setIcon(buildNodeChipDescriptor(context, item.node, density, fontScale))
+ marker.zIndex = item.getZIndex()
+ }
+
+ override fun onClustersChanged(clusters: Set<Cluster<NodeClusterItem>>) {
+ super.onClustersChanged(clusters)
+ unclusteredItems.value = clusters.filterNot { shouldRenderAsCluster(it) }.flatMap { it.items }.toSet()
+ }
}
Served by rngit 1.5.0 - Generated in 0.08s